#!/usr/bin/env node
'use strict'

const fs = require('fs')
const readline = require('readline')
const util = require('util')
const timeout = util.promisify(setTimeout)
const mod = (a, b) => ((a % b) + b) % b
const path = require('path')

const VERSION = '0.2.0'
const USAGE = `Usage: csnaketerm [ -h | --help | -v | --version ]
The classic Snake game, right in your terminal
Invoke without options to play the game (best played on an 80x24 terminal)

Options:

  -h, --help       Print this help message
  -v, --version    Display the current version`
const INSTRUCTIONS = `A snake is trapped in a maze whose body is denoted by '@', is initially 3 units
long and moves 1 unit in one of the four cardinal directions per tick. Your
mission is to guide the snake's movement to ensure that it survives for as long
as possible. The snake's direction of movement can be controlled by the arrow
keys; however, it can only perform 90 degree turns so it cannot make a U-turn in
a single tick.

The snake may collide with the following obstacles leading to its death:

- Walls in the maze denoted by '#', or
- Its own body

Unless there is a wall, the snake will wrap around to the other end of the 80x24
terminal screen once it reaches one end of the screen.

While moving across the maze and avoiding obstacles, the player (i.e. you) gains
1 point per tick where the snake is still alive. But within the maze, the snake
may also consume food pellets denoted by '*' by colliding with them which gains
the player 10 extra points per pellet. A side effect is that each pellet
consumed increases the length of the snake by 1 unit, so it becomes increasingly
difficult to prevent the snake from colliding with itself (or the walls of the
maze) as more and more pellets are consumed.`
const INVALID_OPTION = '\nInvalid option specified - please choose one of the options listed above'
const [ CELL_EMPTY, CELL_WALL, CELL_SNAKE, CELL_FOOD ] = [ 0, 1, 2, 3 ]
const [ DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT ] = [ 0, 1, 2, 3 ]
const MAX_FOOD_PELLETS = 5
const TERM_ROWS = 24
const TERM_COLS = 80
const UNCONFINED = `................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................`
const WALLED = `################################################################################
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
#                                                                              #
################################################################################`
const LABYRINTH = `############################################################################  ##
#             #           #       #       #       #       #       #       #    #
#             #       #       #       #       #       #       #       #        #
##########    #   #########################################################    #
                                                                          #    .
                                                                          #    .
####  #  ##############    ################################################    #
#  #  #  #            #     #     #   #     #       #     #   #       #        #
#  #  #  #                  #           #   #   #   #             #   #        #
#  #  ####            #         #   #   #       #       #   #     #       #    #
#     #  ##########   ##########################################################
#  #              #         #  #  #  #  #  #  #  #  #  #  #        #     #     #
#  #  #  #  ####  #         #  #     #     #     #  #  #  #     #  #  #  #     #
#  #  ####  #  #  #         #  #  #  #  #  #  #  #  #  #     #  #  #  #  #     #
#  #        #  #  #         #     #  #  #        #  #        #  #  #  #  #     #
#  ##########  ####         #  #  #  #  #  #  #  #           #  #  #  #  #     #
#  #           #  #         #  #  #           #  #  #  #  #     #  #  #  #     #
#  #           #  #            #  #  #  #  #  #     #  #  #     #     #        #
#  ##########  #  ##########################################################  ##
#  #        #        #     #        #            #                #            #
#  ###   ####        #  #  #   #    #     #      #       #        #            #
#                    #  #  #   #    #     #      #       #        #            #
#                       #      #          #              #                     #
############################################################################  ##`
const HIGH_SCORES_FILE_FORMAT = /^((0|[1-9]\d*)(\,(0|[1-9]\d*)){11})$/

if (process.argv.length > 3) {
  console.log(USAGE)
  process.exit(1)
}

if (process.argv.length === 3) {
  switch (process.argv[2]) {
  case '-h':
  case '--help':
    console.log(USAGE)
    process.exit()
  case '-v':
  case '--version':
    console.log(VERSION)
    process.exit()
  default:
    console.log(USAGE)
    process.exit(1)
  }
}

readline.emitKeypressEvents(process.stdin)
if (process.stdin.isTTY)
  process.stdin.setRawMode(true)

let highScores
let highScoresFilePath

async function initialize() {
  if (typeof process.env.HOME !== 'string') {
    console.error('Fatal error: Expected environment variable HOME to be defined')
    process.exit(1)
  }
  if (!path.isAbsolute(process.env.HOME)) {
    console.error('Fatal error: Expected environment variable HOME to be an absolute path')
    process.exit(1)
  }
  highScoresFilePath = path.join(process.env.HOME, '.csnaketerm')
  if (!fs.existsSync(highScoresFilePath)) {
    highScores = {
      'Unconfined': {
        'Easy': 0,
	'Medium': 0,
	'Hard': 0,
	'Insane': 0
      },
      'Walled': {
        'Easy': 0,
	'Medium': 0,
	'Hard': 0,
	'Insane': 0
      },
      'Labyrinth': {
        'Easy': 0,
	'Medium': 0,
	'Hard': 0,
	'Insane': 0
      }
    }
  } else {
    try {
      highScores = await fs.promises.readFile(highScoresFilePath)
      highScores = highScores.toString().trim()
      if (!HIGH_SCORES_FILE_FORMAT.test(highScores))
        throw new Error(`Fatal error: The user data located at ${highScoresFilePath} appears to be corrupted. Deleting the file and restarting the game is the simplest solution, but note that you will lose all in-game progress.`)
      highScores = highScores.split`,`.map(n => +n)
      highScores = {
        'Unconfined': {
          'Easy': highScores[0],
          'Medium': highScores[1],
          'Hard': highScores[2],
          'Insane': highScores[3]
	},
        'Walled': {
          'Easy': highScores[4],
          'Medium': highScores[5],
          'Hard': highScores[6],
          'Insane': highScores[7]
	},
	'Labyrinth': {
          'Easy': highScores[8],
          'Medium': highScores[9],
          'Hard': highScores[10],
          'Insane': highScores[11]
	}
      }
    } catch (err) {
      console.error(err)
      process.exit(1)
    }
  }
  mainMenu()
}

async function mainMenu() {
  console.clear()
  console.log(`csnaketerm, v${VERSION}`)
  console.log('The classic Snake game, right in your terminal')
  console.log('Choose an action by pressing the corresponding key:\n')
  console.log('  S: Start')
  console.log('  I: Instructions')
  console.log('  H: High Scores')
  console.log('  Q: Quit')
  process.stdin.resume()
  process.stdin.once('keypress', async (str, key) => {
    process.stdin.pause()
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    switch (str) {
    case 's':
    case 'S':
      mazeSelectionMenu()
      break
    case 'i':
    case 'I':
      instructionMenu()
      break
    case 'h':
    case 'H':
      highScoresMenu()
      break
    case 'q':
    case 'Q':
      console.clear()
      try {
        await fs.promises.writeFile(highScoresFilePath, `${highScores['Unconfined']['Easy']},${highScores['Unconfined']['Medium']},${highScores['Unconfined']['Hard']},${highScores['Unconfined']['Insane']},${highScores['Walled']['Easy']},${highScores['Walled']['Medium']},${highScores['Walled']['Hard']},${highScores['Walled']['Insane']},${highScores['Labyrinth']['Easy']},${highScores['Labyrinth']['Medium']},${highScores['Labyrinth']['Hard']},${highScores['Labyrinth']['Insane']}`)
      } catch (err) {
        console.error(err)
	process.exit(1)
      }
      process.exit()
    default:
      console.log(INVALID_OPTION)
      await timeout(1000)
      mainMenu()
    }
  })
}

async function instructionMenu() {
  console.clear()
  console.log('Instructions')
  console.log('='.repeat(TERM_COLS))
  console.log(INSTRUCTIONS)

  console.log('\nPress any key to return to the main menu')
  process.stdin.resume()
  process.stdin.once('keypress', async (str, key) => {
    process.stdin.pause()
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    mainMenu()
  })
}

async function mazeSelectionMenu() {
  console.clear()
  console.log('Select a maze by pressing the corresponding key:\n')

  console.log('  U. Unconfined')
  console.log('  W. Walled')
  console.log('  L. Labyrinth')
  process.stdin.resume()
  process.stdin.once('keypress', async (str, key) => {
    process.stdin.pause()
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    switch (str) {
    case 'u':
    case 'U':
      difficultySelectionMenu('Unconfined')
      break
    case 'w':
    case 'W':
      difficultySelectionMenu('Walled')
      break
    case 'l':
    case 'L':
      difficultySelectionMenu('Labyrinth')
      break
    default:
      console.log(INVALID_OPTION)
      await timeout(1000)
      mazeSelectionMenu()
    }
  })
}

async function highScoresMenu() {
  console.clear()
  console.log('High Scores')
  console.log('='.repeat(TERM_COLS))
  for (let maze in highScores) {
    console.log(maze)
    for (let difficulty in highScores[maze])
      console.log(`  ${difficulty}: ${highScores[maze][difficulty]}`)
  }
  console.log('\nPress any key to return to the main menu')
  process.stdin.resume()
  process.stdin.once('keypress', async (str, key) => {
    process.stdin.pause()
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    mainMenu()
  })
}

async function difficultySelectionMenu(maze) {
  console.clear()
  console.log('Select a difficulty by pressing the corresponding key:\n')

  console.log('  E. Easy')
  console.log('  M. Medium')
  console.log('  H. Hard')
  console.log('  I. Insane')
  process.stdin.resume()
  process.stdin.once('keypress', async (str, key) => {
    process.stdin.pause()
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    switch (str) {
    case 'e':
    case 'E':
      startGame(maze, 'Easy')
      break
    case 'm':
    case 'M':
      startGame(maze, 'Medium')
      break
    case 'h':
    case 'H':
      startGame(maze, 'Hard')
      break
    case 'i':
    case 'I':
      startGame(maze, 'Insane')
      break
    default:
      console.log(INVALID_OPTION)
      await timeout(1000)
      difficultySelectionMenu(maze)
    }
  })
}

function parseMaze(maze) {
  if (typeof maze !== 'string')
    throw new TypeError('parseMaze(maze): Expected \'maze\' to be a string')
  let result = maze.split`\n`
    .map(row => row.split``
      .map(cell => cell === '*' ?
          CELL_FOOD :
        cell === '#' ?
          CELL_WALL :
          CELL_EMPTY))
  if (result.length !== TERM_ROWS)
    throw new Error(`parseMaze(maze): Expected 'maze' to have exactly ${TERM_ROWS} rows`)
  for (let row of result)
    if (row.length !== TERM_COLS)
      throw new Error(`parseMaze(maze): Expected 'maze' to have exactly ${TERM_COLS} columns`)
  return result
}

function placeSnakeInMaze(maze, snake) {
  for (let cell of snake)
    maze[cell.row][cell.col] = CELL_SNAKE
}

function tryPlaceNPelletsInMaze(maze, n) {
  let emptyCells = []
  for (let row = 0; row < TERM_ROWS; ++row)
    for (let col = 0; col < TERM_COLS; ++col)
      if (maze[row][col] === CELL_EMPTY)
        emptyCells.push({ row, col })
  emptyCells.sort((a, b) => 0.5 - Math.random())
  emptyCells = emptyCells.slice(0, n)
  for (let emptyCell of emptyCells)
    maze[emptyCell.row][emptyCell.col] = CELL_FOOD
}

function displayMaze(maze) {
  return maze.map(row =>
    row.map(cell =>
      cell === CELL_EMPTY ?
        ' ' :
      cell === CELL_WALL ?
        '#' :
      cell === CELL_SNAKE ?
        '@' :
        '*')
      .join``)
    .join`\n`
}

function logFatalError(affectedVar) {
  console.clear()
  console.error('startGame(maze, difficulty): Unreachable statement reached due to unexpected')
  console.error(`value of '${affectedVar}'!`)
  console.error('This is a fatal error - please report this bug at')
  console.error('https://github.com/DonaldKellett/csnaketerm/issues')
}

function isPassable(maze, cell) {
  return maze[cell.row][cell.col] === CELL_EMPTY || maze[cell.row][cell.col] === CELL_FOOD
}

function isPellet(maze, cell) {
  return maze[cell.row][cell.col] === CELL_FOOD
}

async function startGame(maze, difficulty) {
  console.clear()
  let mazeStr = maze
  switch (maze) {
    case 'Unconfined':
      maze = parseMaze(UNCONFINED)
      break
    case 'Walled':
      maze = parseMaze(WALLED)
      break
    case 'Labyrinth':
      maze = parseMaze(LABYRINTH)
      break
    default:
      logFatalError('maze')
      process.exit(1)
  }
  let tickDurationMs
  switch (difficulty) {
    case 'Easy':
      tickDurationMs = 500
      break
    case 'Medium':
      tickDurationMs = 250
      break
    case 'Hard':
      tickDurationMs = 125
      break
    case 'Insane':
      tickDurationMs = 62.5
      break
    default:
      logFatalError('difficulty')
      process.exit(1)
  }
  let snake = [
    { row: 1, col: 3 },
    { row: 1, col: 2 },
    { row: 1, col: 1 }
  ]
  placeSnakeInMaze(maze, snake)
  tryPlaceNPelletsInMaze(maze, MAX_FOOD_PELLETS)
  process.stdout.write(displayMaze(maze))
  let snakeDirPrev = DIR_RIGHT
  let snakeDirCur = DIR_RIGHT
  process.stdin.resume()
  process.stdin.on('keypress', async (str, key) => {
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    switch (snakeDirPrev) {
    case DIR_UP:
    case DIR_DOWN:
      if (key.name === 'left')
        snakeDirCur = DIR_LEFT
      else if (key.name === 'right')
        snakeDirCur = DIR_RIGHT
      break
    case DIR_LEFT:
    case DIR_RIGHT:
      if (key.name === 'up')
        snakeDirCur = DIR_UP
      else if (key.name === 'down')
        snakeDirCur = DIR_DOWN
      break
    default:
      logFatalError('snakeDirPrev')
      process.exit(1)
    }
  })
  let score = 0
  let refreshInterval = setInterval(() => {
    process.stdin.pause()
    console.clear()
    ++score
    let nextHeadPos = {}
    switch (snakeDirCur) {
    case DIR_UP:
      nextHeadPos.row = mod(snake[0].row - 1, TERM_ROWS)
      nextHeadPos.col = mod(snake[0].col, TERM_COLS)
      break
    case DIR_DOWN:
      nextHeadPos.row = mod(snake[0].row + 1, TERM_ROWS)
      nextHeadPos.col = mod(snake[0].col, TERM_COLS)
      break
    case DIR_LEFT:
      nextHeadPos.row = mod(snake[0].row, TERM_ROWS)
      nextHeadPos.col = mod(snake[0].col - 1, TERM_COLS)
      break
    case DIR_RIGHT:
      nextHeadPos.row = mod(snake[0].row, TERM_ROWS)
      nextHeadPos.col = mod(snake[0].col + 1, TERM_COLS)
      break
    default:
      logFatalError('snakeDirCur')
      process.exit(1)
    }
    if (!isPassable(maze, nextHeadPos)) {
      process.stdin.removeAllListeners('keypress')
      clearInterval(refreshInterval)
      gameOverScreen(mazeStr, difficulty, score)
      return
    }
    if (isPellet(maze, nextHeadPos)) {
      score += 10
      snake.unshift(nextHeadPos)
      maze[nextHeadPos.row][nextHeadPos.col] = CELL_SNAKE
      tryPlaceNPelletsInMaze(maze, 1)
    } else {
      maze[snake[snake.length - 1].row][snake[snake.length - 1].col] = CELL_EMPTY
      snake.pop()
      snake.unshift(nextHeadPos)
      maze[nextHeadPos.row][nextHeadPos.col] = CELL_SNAKE
    }
    process.stdout.write(displayMaze(maze))
    snakeDirPrev = snakeDirCur
    process.stdin.resume()
  }, tickDurationMs)
}

async function gameOverScreen(maze, difficulty, score) {
  console.clear()
  console.log('Game Over')
  console.log('='.repeat(TERM_COLS))
  let isNewHighScore = score > highScores[maze][difficulty]
  if (isNewHighScore)
    console.log('You achieved a new high score!')
  console.log(`You scored: ${score}`)
  if (!isNewHighScore)
    console.log(`Your high score: ${highScores[maze][difficulty]}`)
  if (isNewHighScore)
    highScores[maze][difficulty] = score

  console.log('\nPress any key to return to the main menu')
  process.stdin.resume()
  process.stdin.once('keypress', async (str, key) => {
    process.stdin.pause()
    if (key && key.ctrl && key.name === 'c') {
      console.clear()
      process.exit(1)
    }
    mainMenu()
  })
}

initialize()
